iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
Modern Web

從實務需求出發:前端視覺與 UI 互動開發實踐系列 第 25 篇

【Day 25】網頁互動:用 pointer events 拆解拖曳填答

  • 分享至 

  • xImage
  •  

雖然開頭說會以網頁動畫做為最後一個子題,但是寫到後面發現預先規劃好的內容,比起動畫,更偏向滑鼠、滾輪事件這類互動效果。到最後幾天突然鬼轉,實作範例存量不夠了有沒有 😂

今天想來分享拖曳功能。以前的需求都是列表內拖曳選項來改變排序,所以直接使用 vue-draggable-plus 套件處理。前陣子的專案遇到不一樣的拖曳操作,是要將下方選項拖曳到上方地圖的圖釘,選項需要被拖曳出列表外,還要在靠近圖釘放開後將選項填寫進去。剛好利用這次機會,研究看看如何寫出拖曳效果。

今天的範例都是用 Vue 寫成,那麼我們開始吧!


一、不用 HTML 原生 draggable API

一開始問 AI 與 google 搜尋時,得知 HTML 有內建拖曳機制,透過替元素加上 draggable="true",再監聽 dragstart、dragover、drop 就能使用。但是有下列限制:

  • 該 API 底層是基於滑鼠事件而設計的,在行動瀏覽器上的支援度不一致,常需要額外處理。
  • 拖曳時跟著 pointer 跑的半透明影像,預設由瀏覽器生成。雖然可以用 setDragImage() 換成自訂圖片,但它是一張靜態的影像,無法在拖曳過程中隨狀態變化。
  • 拖曳時的進階視覺效果,例如:原本位置留下空位、與其他元素交換位置等,處理起來很麻煩。

反觀 Pointer Events 則是把滑鼠、觸控、手寫筆統一成同一組事件,寫一次就能支援不同裝置;而樣式客製化就交由 CSS 設定。


二、拖曳基本流程

不管多複雜的拖曳,基本都是同一個流程:

  1. pointerdown:記下起點,開始拖曳。
  2. pointermove:更新位置。
  3. pointerup:結束,決定要不要放置。

我們先來宣告對應的事件 function,並綁在要拖曳的選項上。這邊沒有把 pointermove 和 pointerup 直接綁上 DOM,而是在 pointerdown 發生時才往 window 掛上去,結束時再移除。是因為當使用者拖得快時,pointer 常常會跑出選項的範圍,如果監聽掛在選項上,事件就斷掉。

<template>
  <div
    v-for="option in options"
    :key="option.id"
    class="option"
    @pointerdown="onDown($event, option)"
  >
    {{ option.name }}
  </div>
</template>

<script setup>
import { ref, onBeforeUnmount } from 'vue'

const drag = ref(null) // 紀錄拖曳狀態用,沒在拖曳時是 null

const onMove = (e) => {
  // 後面步驟再補
}

const endDrag = () => {
  drag.value = null
  window.removeEventListener('pointermove', onMove)
  window.removeEventListener('pointerup', endDrag)
  window.removeEventListener('pointercancel', endDrag)
}

const onDown = (e, option) => {
  drag.value = { optionId: option.id, startX: e.clientX, startY: e.clientY, moved: false }

  window.addEventListener('pointermove', onMove)
  window.addEventListener('pointerup', endDrag)
  window.addEventListener('pointercancel', endDrag)
}

onBeforeUnmount(endDrag) // 拖曳中離開頁面時,把監聽清掉
</script>

另外,監聽掛在 window 上,元件被卸載時 Vue 不會自動清掉。如果使用者正在拖曳時就切換頁面,監聽還在,卻沒有對應的畫面,所以要在 onBeforeUnmount (元件即將被卸載)裡呼叫一次 endDrag。

💡 pointercancel 也要一起監聽、一起移除,否則被中斷時監聽會殘留。這個事件在步驟 3 會再提到。


三、實作:從按下到放開

基礎打好了,接下來照事件發生的順序,一步一步把細節補上。每個階段都有各自會踩的坑,就在遇到的那一步順便處理。

步驟 0:先把 CSS 準備好

拖曳開始之前,先在 CSS 裡設定好各個元素的互動相關屬性。

/* 1. 可拖曳的選項 */
.option {
  cursor: grab;
  user-select: none;
  touch-action: none; /* 這個元素上的觸控,瀏覽器不要自己處理手勢 */
}

/* 2. 跟著 pointer 跑的 ghost */
.ghost {
  position: fixed;
  pointer-events: none; /* 不然 ghost 會擋住後面的命中判定 */
}

/* 3. 拖曳中鎖住整個頁面的游標與文字選取(拖曳中要加上的 class) */
body.is-dragging,
body.is-dragging * {
  cursor: grabbing !important;
  user-select: none !important;
}

最容易漏掉 touch-action: none。手指在螢幕上拖,瀏覽器的預設行為是「捲動頁面」,一旦瀏覽器決定接手,就會對這個 pointer 發出 pointercancel,拖曳就會中斷。所以需要這行告訴瀏覽器「這裡的觸控手勢我自己處理」。

步驟 1:pointerdown,決定要不要開始

當使用者按下選項的瞬間,依序處理:過濾掉不該觸發的情況 → 記錄起點和偏移量 → 掛上後續事件的監聽。

const onDown = (e, option) => {
  // 是滑鼠的話,只有左鍵會觸發
  if (e.pointerType === 'mouse' && e.button !== 0) return
  if (drag.value) endDrag()

  // 記錄 pointer 在選項內的偏移量,以及顯示上需要的資訊
  const rect = e.currentTarget.getBoundingClientRect()
  drag.value = {
    optionId: option.id,
    name: option.name,
    x: e.clientX, // pointer 目前位置,之後 pointermove 會更新
    y: e.clientY,
    startX: e.clientX,
    startY: e.clientY,
    ox: e.clientX - rect.left, // pointer 距離選項左邊多遠
    oy: e.clientY - rect.top, // pointer 距離選項上邊多遠
    hoverId: null, // 目前命中的圖釘
    moved: false,
  }

  window.addEventListener('pointermove', onMove)
  window.addEventListener('pointerup', endDrag)
  window.addEventListener('pointercancel', endDrag)
}
  • ox、oy 偏移量的作用: 使用者抓的是選項的中間,如果直接把 ghost 的 left、top 設成 pointer 座標,ghost 的左上角會跳到 pointer 位置,畫面會「彈」一下。先記下 pointer 距離選項左上角多遠,之後每次都扣掉,ghost 就會維持在使用者抓的位置。

  • 座標系要一致。 clientX / clientY 和 getBoundingClientRect() 都是相對於視窗左上角,不受捲動影響,所以可以直接相減。ghost 定位用 position: fixed,也是相對於視窗定位,這三個剛好對得上,不用另外換算距離。

步驟 2:pointermove,真的開始拖了嗎

使用者按下後開始拖動,pointermove 方法中依序處理:設立門檻判斷是否開始 → 顯示 ghost → 更新位置 → 找出目標圖釘。

<template>
  <!-- 拖曳中才出現,位置由 ghostStyle 決定 -->
  <div v-if="drag?.moved" class="ghost" :style="ghostStyle">{{ drag.name }}</div>
</template>

<script setup>
import { computed } from 'vue'

// ghost 位置 = pointer 座標 - 按下時的偏移量
const ghostStyle = computed(() => ({
  left: `${drag.value.x - drag.value.ox}px`,
  top: `${drag.value.y - drag.value.oy}px`,
}))

const onMove = (e) => {
  if (!drag.value) return

  // 1. 門檻:位移超過 3px 才算真的開始拖
  if (!drag.value.moved) {
    const dx = e.clientX - drag.value.startX
    const dy = e.clientY - drag.value.startY
    if (dx * dx + dy * dy < 9) return  // 3 * 3

    drag.value.moved = true // ghost 因為 v-if 條件成立而出現
    document.body.classList.add('is-dragging')
  }

  // 2. 更新 pointer 位置,ghost 會跟著動
  drag.value.x = e.clientX
  drag.value.y = e.clientY

  // 3. 找出最近的圖釘,在畫面上提示使用者將寫入哪個圖釘。nearestPinId() 的內容請看下一段。
  drag.value.hoverId = nearestPinId(e.clientX, e.clientY)
}
</script>

門檻 是為了避免誤觸。使用者只是想點一下選項,手指或滑鼠卻有些微抖動,就被判定成拖曳。moved 變成 true 之後,ghost 才會因為 v-if 出現(前端框架是由資料狀態驅動畫面),is-dragging 也在這時才加上。

<template>
  <!-- 圖釘們 -->
  <div
    v-for="pin in pins"
    :key="pin.id"
    :ref="(el) => setPinEl(pin.id, el)"
    class="pin"
    :class="{ hover: drag?.moved && drag.hoverId === pin.id }"
  >
    {{ pin.id }}
  </div>
</template>

<script setup>
const pinEls = new Map() // { 圖釘 id: DOM 元素 },不需要響應式所以沒用 ref

// 取得圖釘們的 DOM
const setPinEl = (id, el) => {
  if (el) pinEls.set(id, el)
  else pinEls.delete(id)
}

const nearestPinId = (x, y) => {
  let bestId = null
  let bestDist = 56 // 超過這個距離就不算命中

  for (const [id, el] of pinEls) {
    const r = el.getBoundingClientRect()
    const d = Math.hypot(x - (r.left + r.width / 2), y - (r.top + r.height / 2))
    if (d <= bestDist) {
      bestDist = d
      bestId = id
    }
  }
  return bestId // 沒命中就是 null
}
</script>

命中判定 用距離來計算:對每個圖釘取中心點,算和 pointer 的距離,56px 以內取最近的一個。

這裡刻意不用「pointer 在圖釘的方框內」來判斷。是因為圖釘尺寸偏小,相較之下手指太粗,要求精確對準對使用者而言不太友善。於是給每個圖釘一個比視覺範圍更大的「吸附圈」,讓操作手感變得更好。

💡 如果只需要『剛好碰到才算』,可以改用 document.elementFromPoint(x, y) 直接問瀏覽器「這個座標底下是什麼元素」。不過要注意 ghost 必須要有 pointer-events: none 屬性。

步驟 3:pointerup / pointercancel,放置或放棄

當使用者放開時,依序處理:有效拖曳才更新資料 → 重置資料與移除監聽

const endDrag = (e) => {
  if (!drag.value) return

  const { optionId, hoverId, moved } = drag.value

  // 是 pointerup + 剛剛正在拖曳 + 目標圖釘的 id = 有效
  if (e?.type === 'pointerup' && moved && hoverId != null) {
    placeOption(optionId, hoverId)
  }

  // 設回 null、body 移除 .is-dragging、清除監聽
  drag.value = null
  document.body.classList.remove('is-dragging')
  window.removeEventListener('pointermove', onMove)
  window.removeEventListener('pointerup', endDrag)
  window.removeEventListener('pointercancel', endDrag)
}

pointerup 是使用者主動放開,而 pointercancel 是被系統中斷(例如來電、瀏覽器接手手勢)。這個範例只接受使用者主動放開。

另外,一個選項只能放在一個圖釘。所以放到新圖釘前,placeOption() 要先把這個選項在其他圖釘上的紀錄清掉:

const placements = ref({}) // 已經填寫好資料的對照表 { 圖釘 id: 選項 id }

const placeOption = (optionId, pinId) => {
  const next = { ...placements.value }
  for (const [oldPinId, oldOptionId] of Object.entries(next)) {
    if (oldOptionId === optionId) delete next[oldPinId]
  }
  next[pinId] = optionId
  placements.value = next
}


完整範例

Codepen 連結

Day25 Clear!倒數剩下 5 天!
明天繼續網頁互動的主題,想要來看看 vue-draggable-plus 套件幫我做了什麼、FLIP 又是什麼呢?
明天見!


參考資料


上一篇
【Day 24】動畫進階:讓動畫跟著圖走,SVG 動畫
下一篇
【Day 26】網頁互動:列表排序變動加上 FLIP 動畫
系列文
從實務需求出發:前端視覺與 UI 互動開發實踐 共 26 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言